Python Mock:やさしい入門編 - パート1
著者:Leonardo Giordani - 06/03/2016 Updated on Feb 27, 2019
はじめに
TDDに関する2つの入門記事ですでに強調されているように、テストでは、これから開発する関数やオブジェクトを使用するコードをいくつか書く必要があります。
つまり、パブリックAPIの一部である特定の(外部)関数を分離し、標準的な入力やエッジケースで動作することを実証する必要があるのです。
例えば、パーセンテージを格納するオブジェクト(例:投票結果など)を開発する場合、次の条件をテストする必要があります。
クラスは42%などの標準的なパーセンテージを格納できること
クラスは負のパーセンテージを格納しようとするとエラーになること
クラスは100%より大きいパーセンテージを格納するとエラーになること
テストは冪等であり、孤立していなければならない。数学やコンピュータサイエンスにおける冪等性とは、システムの状態を変えることなく複数回実行できるプロセスをいいます。隔離されているとは、あるテストが自分自身の以前の実行に依存してその動作を変えてはならず、また他のテストの以前の実行(または実行の欠落)にも依存してはならないということです。
このような制限は、システムの一時的な設定や実行順序によってテストが通らないことを保証するものですが、外部のライブラリやシステムを扱う場合や、時間のような本質的に変更可能な概念を扱う場合には大きな問題となります。テストの分野では、このような問題は主にmock(他のオブジェクトのふりをするオブジェクト)を使って対処します。
この連載では、Pythonのmockライブラリをレビューし、その使用例を紹介していきます。もちろん、mockを使ってできることをすべて網羅するわけではありませんが、この強力なライブラリを使い始めるために必要な情報を提供できればと思います。
インストール方法
まず第一に、mockは2008年頃から開発が始まったPythonのライブラリです。mockはPython3.3から標準ライブラリに採用されましたが、他のライブラリを使うことを妨げるものではありません。
Python 3 ユーザーは何もする必要はありませんが、Python 2 プロジェクトでは、システムまたは現在の virtualenv にインストールするために pip install mock を実行する必要があります。
公式ドキュメントはここにあります。非常に詳しく書かれていますので、いつものように時間をかけて読み進めることを強くお勧めします。
基本的なコンセプト
mock とは、テスト用語では、別の(より複雑な)オブジェクトの動作をシミュレートするオブジェクトのことです。ライブラリのオブジェクトを(ユニット)テストする際に、オブジェクトが接続したい他のシステムにアクセスする必要がある場合がありますが、いくつかの理由から、実際にはそれらを実行させたくはありません。
第一の理由は、外部のシステムに接続することは、複雑なテスト環境を持つことを意味し、つまりテストの分離要件を放棄することになります。例えば、オブジェクトがWebサイトに接続したい場合、インターネット接続が稼働していなければならず、リモートのWebサイトがダウンしているとライブラリのテストができません。
2つ目の理由は、ユニットテストのスピードに比べて、外部システムのセットアップが遅いことです。何百ものテストを数秒で実行することを想定していますが、それぞれのテストでリモートサーバーから情報を取得しなければならない場合は、簡単に数桁の時間がかかってしまいます。覚えておいてほしいのですが、テストが遅いということは、開発中にテストを実行できないということであり、その結果、TDDにはあまり使えないということになります。
3つ目の理由はもっと微妙で、外部システムの変更可能な性質に関係しています。
それでは、Pythonでmock を使ってみて、何ができるか見てみましょう。まず、PythonシェルかJupyter Notebookを立ち上げ、ライブラリをインポートします。
code: python
from unittest import mock
Python 2を使用している場合は、mock をインストールして使用します。
code: bash
$ pip install mock
code: python
import mock
mocl ライブラリが提供するメイン・オブジェクトはMockであり、引数なしでインスタンス化することができます。
code: python
m = mock.Mock()
このオブジェクトは、必要に応じてその場でメソッドや属性を生成するという独特の性質を持っています。まず、このオブジェクトの内部を見て、どのような機能があるのかを見てみましょう。
code: python
>> dir(m)
'assert_any_call', 'assert_called_once_with', 'assert_called_with', 'assert_has_calls', 'attach_mock', 'call_args', 'call_args_list', 'call_count', 'called', 'configure_mock', 'method_calls', 'mock_add_spec', 'mock_calls', 'reset_mock', 'return_value', 'side_effect' ご覧のように、Mockオブジェクトにすでに定義されているメソッドがいくつかあります。存在しない属性を読み込んでみましょう
code: python
>> m.some_attribute
<Mock name='mock.some_attribute' id='140222043808432'>
>> dir(m)
'assert_any_call', 'assert_called_once_with', 'assert_called_with', 'assert_has_calls', 'attach_mock', 'call_args', 'call_args_list', 'call_count', 'called', 'configure_mock', 'method_calls', 'mock_add_spec', 'mock_calls', 'reset_mock', 'return_value', 'side_effect', 'some_attribute' ご覧の通り、このクラスはあなたが慣れ親しんできたものとは少し違います。まず、このクラスのインスタンスは、存在しない属性を要求されても AttributeError を発生させず、喜んで Mock 自体の別のインスタンスを返します。次に、アクセスしようとした属性はオブジェクト内に作成されており、それにアクセスすると以前と同じMockオブジェクトが返されます。
code: python
>> m.some_attribute
<Mock name='mock.some_attribute' id='140222043808432'>
Mockオブジェクトは呼び出し可能なオブジェクトです。つまり、属性としてもメソッドとしても機能します。Mockを呼ぼうとすると、呼び出し可能であることを示す括弧を含んだ名前の別のMockが返されます。
ご理解いただけると思いますが、 このようなオブジェクトは他のオブジェクトやシステムを模倣するのに最適なツールです。 というのも、例外を発生させることなく任意の API を公開できるからです。しかし、テストで使用するためには、元のオブジェクトと同じように動作する必要があります。これは、適切な値を返したり操作を実行したりすることを意味します。
戻り値
mock ができる最も単純なことは、Mockを呼び出すたびに指定した値を返すことです。これは、Mockオブジェクトの return_value 属性を設定することで実現できます。
code: python
>> m.some attribute.return_value = 42
>> m.some attribute()
42
これで、オブジェクトはMockオブジェクトを返さなくなり、代わりに return_value 属性に格納された静的な値を返すようになりました。もちろん、関数やオブジェクトのような呼び出し可能なものを格納することもでき、メソッドはそれを返しますが、実行はしません。例を挙げてみましょう。
code: python
>> def print_answer():
... print("42")
...
>>
>> m.some_attribute.return_value = print_answer
>> m.some_attribute()
<function print_answer at 0x7f8df1e3f400>
some_attribute()を呼び出すと、return_valueに格納されている値、つまり関数そのものが返されることがわかります。関数から来た値を返すためには、side_effectという少し複雑なMockオブジェクトの属性を使う必要があります。
副作用
Mockオブジェクトのside_effect パラメータは、非常に強力なツールです。このパラメータは、呼び出し可能オブジェクト(callable)、イテラブルオブジェクト(iterable)、例外(exceptions)の3種類のオブジェクトを受け取り、それに応じて動作を変化させます。
例外を渡した場合、mock はそれを発生させます。
code: python
>> m.some_attribute.side_effect = ValueError('A custom value error')
>> m.some_attribute()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/usr/lib/python3.4/unittest/mock.py", line 902, in __call__
return _mock_self._mock_call(*args, **kwargs)
File "/usr/lib/python3.4/unittest/mock.py", line 958, in _mock_call
raise effect
ValueError: A custom value error
ジェネレータやリスト、タプルなどのイテラブルオブジェクトを渡すと、mock はそのイテラブルオブジェクトの値を返します。例を挙げてみましょう。
code: python
>> m.some_attribute.side_effect = range(3)
>> m.some_attribute()
0
>> m.some_attribute()
1
>> m.some_attribute()
2
>> m.some_attribute()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/usr/lib/python3.4/unittest/mock.py", line 902, in __call__
return _mock_self._mock_call(*args, **kwargs)
File "/usr/lib/python3.4/unittest/mock.py", line 961, in _mock_call
result = next(effect)
StopIteration
お約束のように、このmock はイテラブルオブジェクト(ここでは range オブジェクト)を、ジェネレーターがなくなるまで一度にすべて返します。イテレータのプロトコル(次の記事を参照)によれば、すべてのアイテムが返されると、オブジェクトはStopIteration例外を発生させます。
最後に、おそらく最もよく使われるケースは、callableをside_effectに渡し、side_effectが恥ずかしげもなく同じパラメータでそれを実行するというものです。これは非常に強力で、特に「関数」を考えるのをやめて「callable」を考えるようにした場合に有効です。実際、side_effectはクラスを受け取ってそれを呼び出すこともでき、つまり、オブジェクトをインスタンス化することができます。簡単な例として、引数のない関数を考えてみましょう。
code: python
>> def print_answer():
... print("42")
>> m.some_attribute.side_effect = print_answer
>> m.some_attribute()
42
もう少し複雑な例:引数を持つ関数
code: python
>> def print_number(num):
... print("Number:", num)
...
>> m.some_attribute.side_effect = print_number
>> m.some_attribute.side_effect(5)
Number: 5
そして、最後にクラスを使った例
code: python
>> class Number(object):
... def __init__(self, value):
... self._value = value
... def print_value(self):
... print("Value:", self._value)
...
>> m.some_attribute.side_effect = Number
>> n = m.some_attribute.side_effect(26)
>> n
<__main__.Number object at 0x7f8df1aa4470>
>> n.print_value()
Value: 26
mock を使ったテスト
さて、mock の作り方と、mock に静的な戻り値を与えたり、呼び出し可能なオブジェクトを呼び出したりする方法がわかりました。次は、テストでmock を使用する方法と、mock が提供する機能を見てみましょう。テストフレームワークとして pytest を使用する予定です。
セットアップ
pytest の実行環境を素早くセットアップしたい場合は、ターミナルで以下のコードを実行してください。
訳注:原文では virtualenv を使用していますが、python3 の標準ライブラリ venv に書き換えています。
code: bash
mkdir mockplayground
cd mockplayground
python -m venv venv3
source venv3/bin/enable
pip install --upgrade pip
pip install pytest
echo "norecursedirs=venv*" >> pytest.ini
mkdir tests
touch myobj.py
touch tests/test_mock.py
PYTHONPATH="." pytest
環境変数 PYTHONPATH は、いくつかの簡単なコードをテストするために、Python プロジェクト全体をセットアップする必要をなくす簡単な方法です。
3つのテストタイプ
Sandy Metz氏によると、オブジェクト間のメッセージ(コール)をテストする必要があるのは、以下の3種類だけだそうです。
着信するクエリ(結果に対するアサーション)
着信コマンド(直接のパブリックサイドエフェクトのアサーション)
送信コマンド(呼び出しと引数に関する期待)
オリジナルの講演とスライドは参考資料を参照してくdさい。最終的な表は、スライド番号176に示されています。
ご覧のように、外部のオブジェクトを扱うとき、私たちが知りたいのは、メソッドが呼び出されたかどうかと、呼び出し側がオブジェクトに渡したパラメータの内容だけです。リモート・オブジェクトが正しい結果を返すかどうかはテストしていません。これはmockによってフェイクされ、実際に必要な結果が返されます。
つまり、Mockオブジェクトが提供するメソッドの目的は、Mock自身のどのメソッドを呼び出したのか、呼び出しに使用したパラメータは何かをチェックできるようにすることです。
呼び出しのアサーション
テストにおけるPython mock の使い方を紹介するために、私はTDDの方法論に従って、まずテストを書き、次にテストを通過させるコードを書きます。この記事では、Mockオブジェクトの簡単な概要をお伝えしたいので、実際のユースケースは実装せず、コードも非常に些細なものになります。このシリーズの第2回目では、より興味深いユースケースを紹介するために、実際のクラスをテストして実装します。
外部のオブジェクトを扱うときに最初に興味を持つのは、あるメソッドが呼び出されたかどうかを知ることでしょう。Pythonのmock はこの条件をチェックするためにassert_called_with()メソッドを提供しています。
今回テストするユースケースは以下の通りです。外部オブジェクトを必要とするmyobj.MyObjクラスをインスタンス化する。このクラスは、外部オブジェクトのconnect()メソッドをパラメータなしで呼び出す。
code: python
from unittest import mock
import myobj
def test_instantiation():
external_obj = mock.Mock()
myobj.MyObj(external_obj)
external_obj.connect.assert_called_with()
この単純な例では、myobj.MyObjクラスは、外部オブジェクト、例えばリモートリポジトリやデータベースなどに接続する必要があります。テストのために知る必要があるのは、このクラスが外部オブジェクトの connect() メソッドをパラメータなしで呼び出したかどうかだけです。
そこで、このテストで最初に行うことは、Mockオブジェクトのインスタンスを作成することです。これは外部オブジェクトの偽バージョンであり、その目的はテスト対象のMyObjオブジェクトからの呼び出しを受け入れ、適切な値を返すことだけです。次に、外部オブジェクトを渡してMyObjクラスをインスタンス化します。このクラスが connect() メソッドを呼び出すことを期待して、external_obj.connect.assert_called_with() を呼び出して期待を表現します。
舞台裏では何が起こっているのでしょうか?MyObjクラスは外部オブジェクトを受け取り、その初期化プロセスのどこかで、Mockオブジェクトの connect()メソッドを呼び出し、これによりメソッド自体がMockオブジェクトとして作成されます。この新しいはMockは、呼び出しに使われたパラメータを記録し、その後の assert_called_with() の呼び出しで、メソッドが呼び出され、パラメータが渡されなかったことをチェックします。
pytest を実行すると、テストは明らかに失敗します。
code: bash
$ PYTHONPATH="." pytest
========================================== test session starts ==========================================
platform linux -- Python 3.4.3+, pytest-2.9.0, py-1.4.31, pluggy-0.3.1
rootdir: /home/leo/devel/mockplayground, inifile: pytest.ini
collected 1 items
tests/test_mock.py F
=============================================== FAILURES ================================================
___________________________________________ test_instantiation __________________________________________
def test_instantiation():
external_obj = mock.Mock()
myobj.MyObj(external_obj)
E AttributeError: 'module' object has no attribute 'MyObj'
tests/test_mock.py:6: AttributeError
======================================= 1 failed in 0.03 seconds ========================================
$
次のコードを myobj.py に記述するだけで、テストがパスするようになります。
code: python
class MyObj():
def __init__(self, repo):
repo.connect()
ご覧のとおり、__init__() メソッドは実際に repo.connect() を呼び出しています。ここでrepoは、指定されたAPIを提供するフル機能の外部オブジェクトであることが期待されています。この場合(今のところ)、APIとは単にconnect() メソッドのことです。repoがMockオブジェクトであるときに repo.connect() を呼び出すと、先に示したように、Mockオブジェクトとしてのメソッドが静かに生成されます。
assert_called_with() メソッドでは、呼び出し時に渡したパラメータを確認することもできます。MyObj.setup()メソッドが外部オブジェクトの setup(cache=True, max_connections=256) を呼び出すことを期待していると仮定してみましょう。ご覧のように、呼び出されたメソッドにいくつかの引数(すなわち、cache と max_connections)を渡していますが、呼び出しがまさにこの形式であったことを確認したいと思います。新しいテストは次のようになります。
code: python
def test_setup():
external_obj = mock.Mock()
obj = myobj.MyObj(external_obj)
obj.setup()
external_obj.setup.assert_called_with(cache=True, max_connections=256)
いつものように、最初の実行は失敗します。これはTDDの方法論の一部です。パスしないテストを用意して、それをパスさせるコードを書かなければなりません。
code: bash
$ PYTHONPATH="." pytest
========================================== test session starts ==========================================
platform linux -- Python 3.4.3+, pytest-2.9.0, py-1.4.31, pluggy-0.3.1
rootdir: /home/leo/devel/mockplayground, inifile: pytest.ini
collected 2 items
tests/test_mock.py .F
=============================================== FAILURES ================================================
______________________________________________ test_setup _______________________________________________
def test_setup():
external_obj = mock.Mock()
obj = myobj.MyObj(external_obj)
obj.setup()
E AttributeError: 'MyObj' object has no attribute 'setup'
tests/test_mock.py:14: AttributeError
================================== 1 failed, 1 passed in 0.03 seconds ===================================
$
Mockオブジェクトがどのようなチェックを行うかを示すために、部分的に正しいソリューションを実装してみましょう。
code: python
class MyObj():
def __init__(self, repo):
self._repo = repo
repo.connect()
def setup(self):
self._repo.setup(cache=True)
ご覧のとおり、外部オブジェクトは self._repo に格納されており、self._repo.setup() の呼び出しには max_connections パラメータがないため、テストが期待するものとは異なります。pytest を実行すると、次のような結果が得られます (pytest の出力のほとんどを割愛しました)。
code: bash
E AssertionError: Expected call: setup(cache=True, max_connections=256)
E Actual call: setup(cache=True)
これを見てみると、エラーメッセージには、私たちが期待していたことと、私たちのコードで起こったことが非常に明確に書かれています。
公式ドキュメントにもあるように、Mockオブジェクトは次のようなメソッドや属性を提供しています:
assert_called_once_with
assert_any_call
assert_has_calls
assert_not_called
called
call_count.
これらはそれぞれ、コールに関するmockの動作の異なる側面を探るもので、その説明と一緒に提供されている例を確認してください。
最後に
シリーズの最初のパートでは、Mockオブジェクトの動作と、戻り値をシミュレートしたり呼び出しをテストしたりするために提供されるメソッドについて説明しました。Mockオブジェクトは非常に強力なツールであり、 外部の機能に依存した複雑で時間のかかるテストの作成を避けることができます。 そのため、テストの主な目的であるコードのチェックを継続的に支援することができません。
次回は、与えられたオブジェクトからMockメソッドを自動的に作成する方法や、パッチデコレーターとコンテキストマネージャーが提供する非常に重要なパッチメカニズムについてご紹介します。
参考資料
Sandy Metzの発表:
https://www.youtube.com/watch?v=URSWYvyc42M
Part 1 of the Python Mocks: a gentle introduction series